<template>
  <div class="p-6 space-y-6">
    <div class="text-2xl font-bold">TXT 合并与去重</div>
    <div class="text-sm text-gray-500">
      支持：合并文件夹TXT、单文件去重、A 扣
      B。结果不预览，直接下载，适配千万级行。
    </div>

    <!-- 通用设置 -->
    <el-card shadow="never" class="space-y-3">
      <template #header>
        <div class="font-semibold">通用设置</div>
      </template>
      <div class="flex items-center gap-2">
        <el-checkbox v-model="opts.trimWhitespace">忽略首尾空白</el-checkbox>
        <el-checkbox v-model="opts.ignoreCase">忽略大小写</el-checkbox>
        <el-checkbox v-model="opts.ignoreEmpty">忽略空行</el-checkbox>
        <el-checkbox v-model="opts.outputCRLF">
          输出使用 Windows 换行 CRLF
        </el-checkbox>
        <el-tag type="success" class="ml-[30px]">保留首次出现顺序</el-tag>
      </div>
    </el-card>

    <!-- 1) 合并 -->
    <el-card shadow="never">
      <template #header>
        <div class="text-lg font-semibold">
          1) 合并一个或多个文件夹中的 TXT 为一个文件
        </div>
      </template>

      <div class="text-gray-500 text-sm mb-3 flex items-center gap-3">
        <el-checkbox v-model="mergeDedup">合并时去重</el-checkbox>
        <span>
          提示：选择“多个文件夹”或“多个TXT文件”都可以；浏览器需支持目录选择
        </span>
      </div>

      <div class="flex flex-col gap-3">
        <div class="flex flex-wrap items-center gap-2">
          <el-button type="primary" @click="pickFolders">选择文件夹</el-button>
          <input
            ref="multiFilesRef"
            class="hidden"
            type="file"
            multiple
            accept=".txt"
            @change="onMultiFilesPicked"
          />
          <el-button @click="triggerMultiFiles">或 选择多个TXT文件</el-button>
          <span v-if="mergeFiles.length" class="text-sm text-gray-500">
            已选 {{ mergeFiles.length }} 个
          </span>
        </div>

        <div v-if="mergeFiles.length" class="text-gray-500 text-xs">
          {{ mergeSummary }}
        </div>

        <div>
          <el-button
            type="success"
            :disabled="!mergeFiles.length || working"
            :loading="working"
            @click="startMerge"
          >
            开始合并
          </el-button>
        </div>
      </div>
    </el-card>

    <!-- 2) 单文件去重 -->
    <el-card shadow="never">
      <template #header>
        <div class="text-lg font-semibold">2) 单个 TXT 文件去重</div>
      </template>

      <div class="flex flex-col gap-3">
        <div class="flex items-center gap-2">
          <input
            ref="singleFileRef"
            class="hidden"
            type="file"
            accept=".txt"
            @change="onSingleFilePicked"
          />
          <el-button @click="triggerSingleFile">选择文件</el-button>
          <span class="text-sm text-gray-500">
            {{ singleFile ? singleFile.name : "未选择文件" }}
          </span>
        </div>

        <div v-if="singleFile" class="text-gray-500 text-xs">
          {{ fileDesc(singleFile) }}
        </div>

        <div>
          <el-button
            type="success"
            :disabled="!singleFile || working"
            :loading="working"
            @click="startDedupSingle"
          >
            开始去重
          </el-button>
        </div>
      </div>
    </el-card>

    <!-- 3) A 扣 B -->
    <el-card shadow="never">
      <template #header>
        <div class="text-lg font-semibold">
          3) 两个 TXT 文件对比去重（A 扣 B）
        </div>
      </template>

      <div class="flex flex-col gap-4">
        <div class="flex items-center gap-2">
          <input
            ref="fileARef"
            class="hidden"
            type="file"
            accept=".txt"
            @change="onFileAPicked"
          />
          <el-button @click="triggerFileA">选择 A</el-button>
          <span class="text-sm text-gray-500">{{
            fileA ? fileA.name : "未选择 A"
          }}</span>
        </div>
        <div class="flex items-center gap-2">
          <input
            ref="fileBRef"
            class="hidden"
            type="file"
            accept=".txt"
            @change="onFileBPicked"
          />
          <el-button @click="triggerFileB">选择 B</el-button>
          <span class="text-sm text-gray-500">{{
            fileB ? fileB.name : "未选择 B"
          }}</span>
        </div>

        <div v-if="fileA || fileB" class="text-gray-500 text-xs">
          <span v-if="fileA">A：{{ fileDesc(fileA) }}</span>
          <span v-if="fileA && fileB" class="mx-3">|</span>
          <span v-if="fileB">B：{{ fileDesc(fileB) }}</span>
        </div>

        <div>
          <el-button
            type="success"
            :disabled="!fileA || !fileB || working"
            :loading="working"
            @click="startDiff"
          >
            开始对比去重
          </el-button>
        </div>
      </div>
    </el-card>

    <!-- 状态 -->
    <el-card v-if="status.message || working" shadow="never">
      <template #header>
        <div class="font-semibold">任务状态</div>
      </template>
      <div class="space-y-2">
        <div class="text-sm">{{ status.message }}</div>
        <el-progress
          :percentage="status.progress"
          :status="working ? undefined : 'success'"
        />
        <div class="text-xs text-gray-500">
          已处理行：{{ formatNumber(status.lines) }}，已输出行：{{
            formatNumber(status.outputLines)
          }}
        </div>
      </div>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";

type Options = {
  trimWhitespace: boolean;
  ignoreCase: boolean;
  ignoreEmpty: boolean;
  outputCRLF: boolean;
  preserveFirst: boolean;
};

const opts = reactive<Options>({
  trimWhitespace: true,
  ignoreCase: false,
  ignoreEmpty: true,
  outputCRLF: true,
  preserveFirst: true
});

// “合并时去重”开关
const mergeDedup = ref(true);

const working = ref(false);
const status = reactive({
  message: "",
  progress: 0,
  lines: 0,
  outputLines: 0
});

function resetStatus() {
  status.message = "";
  status.progress = 0;
  status.lines = 0;
  status.outputLines = 0;
}
function setStatus(msg: string, p?: number) {
  status.message = msg;
  if (typeof p === "number")
    status.progress = Math.min(100, Math.max(0, Math.round(p)));
}

const multiFilesRef = ref<HTMLInputElement | null>(null);
const singleFileRef = ref<HTMLInputElement | null>(null);
const fileARef = ref<HTMLInputElement | null>(null);
const fileBRef = ref<HTMLInputElement | null>(null);

const mergeFiles = ref<File[]>([]);
const singleFile = ref<File | null>(null);
const fileA = ref<File | null>(null);
const fileB = ref<File | null>(null);

function triggerMultiFiles() {
  multiFilesRef.value?.click();
}
function onMultiFilesPicked(e: Event) {
  const input = e.target as HTMLInputElement;
  mergeFiles.value = input.files
    ? Array.from(input.files).filter(f => f.name.toLowerCase().endsWith(".txt"))
    : [];
}

async function pickFolders() {
  try {
    // @ts-ignore
    if (!window.showDirectoryPicker) {
      ElMessage.warning("当前浏览器不支持目录选择，将回退到多文件选择");
      triggerMultiFiles();
      return;
    }
    // 单次或多次选择目录，聚合
    const dirs: FileSystemDirectoryHandle[] = [];
    // @ts-ignore
    const pickOne = async () => await window.showDirectoryPicker();
    while (true) {
      try {
        const h = await pickOne();
        dirs.push(h);
        const more = await ElMessageBox.confirm(
          "已选择 1 个目录，是否继续选择更多目录？",
          "继续选择",
          {
            confirmButtonText: "继续",
            cancelButtonText: "完成",
            type: "info"
          }
        )
          .then(() => true)
          .catch(() => false);
        if (!more) break;
      } catch (e) {
        if ((e as any).name === "AbortError") break;
        throw e;
      }
    }
    const files: File[] = [];
    for (const d of dirs) {
      const collected = await collectTxtFilesFromDir(d);
      files.push(...collected);
    }
    mergeFiles.value = files;
  } catch (e) {
    if ((e as any).name !== "AbortError") {
      console.error(e);
      ElMessage.error("选择目录失败");
    }
  }
}

async function collectTxtFilesFromDir(
  dirHandle: FileSystemDirectoryHandle
): Promise<File[]> {
  const files: File[] = [];
  // @ts-ignore
  for await (const [name, handle] of (dirHandle as any).entries()) {
    if (handle.kind === "file" && name.toLowerCase().endsWith(".txt")) {
      const f = await (handle as FileSystemFileHandle).getFile();
      files.push(f);
    } else if (handle.kind === "directory") {
      const sub = await collectTxtFilesFromDir(
        handle as FileSystemDirectoryHandle
      );
      files.push(...sub);
    }
  }
  return files;
}

function triggerSingleFile() {
  singleFileRef.value?.click();
}
function onSingleFilePicked(e: Event) {
  const input = e.target as HTMLInputElement;
  singleFile.value = input.files && input.files[0] ? input.files[0] : null;
}

function triggerFileA() {
  fileARef.value?.click();
}
function onFileAPicked(e: Event) {
  const input = e.target as HTMLInputElement;
  fileA.value = input.files && input.files[0] ? input.files[0] : null;
}
function triggerFileB() {
  fileBRef.value?.click();
}
function onFileBPicked(e: Event) {
  const input = e.target as HTMLInputElement;
  fileB.value = input.files && input.files[0] ? input.files[0] : null;
}

const mergeSummary = computed(() => {
  if (!mergeFiles.value.length) return "";
  const totalBytes = mergeFiles.value.reduce((a, f) => a + f.size, 0);
  const preview = mergeFiles.value
    .slice(0, 6)
    .map(f => f.name)
    .join("、");
  const more = mergeFiles.value.length > 6 ? " …" : "";
  return `已选 ${mergeFiles.value.length} 个TXT（共 ${formatBytes(totalBytes)}）：${preview}${more}`;
});

function fileDesc(f?: File | null) {
  if (!f) return "";
  return `${f.name}（${formatBytes(f.size)}）`;
}

function formatBytes(n: number) {
  if (n < 1024) return `${n} B`;
  const units = ["KB", "MB", "GB"];
  let i = -1;
  do {
    n = n / 1024;
    i++;
  } while (n >= 1024 && i < units.length - 1);
  return `${n.toFixed(2)} ${units[i]}`;
}
function formatNumber(n: number) {
  return new Intl.NumberFormat().format(n);
}

function normalizeLine(line: string, options: Options): string {
  let s = line;
  if (options.trimWhitespace) s = s.trim();
  try {
    s = s.normalize("NFC");
  } catch {}
  if (options.ignoreCase) s = s.toLowerCase();
  return s;
}

const NEWLINE_LF = "\n";
const NEWLINE_CRLF = "\r\n";

function downloadText(text: string, filename: string) {
  const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}

async function streamFileLines(
  file: File,
  onLine: (line: string) => void,
  onProgress?: (processedBytes: number, totalBytes: number) => void
) {
  const reader = file.stream().getReader();
  const decoder = new TextDecoder("utf-8");
  let { value, done } = await reader.read();
  let leftover = "";
  let processed = 0;
  const total = file.size;

  while (!done) {
    const text = decoder.decode(value, { stream: true });
    processed += value!.byteLength;
    let combined = leftover + text;
    combined = combined.replace(/\r\n/g, "\n");
    const parts = combined.split("\n");
    leftover = parts.pop() ?? "";
    for (const part of parts) onLine(part);
    onProgress?.(processed, total);
    ({ value, done } = await reader.read());
  }
  const tail = decoder.decode();
  let finalText = leftover + tail;
  finalText = finalText.replace(/\r\n/g, "\n");
  if (finalText.length) {
    const lines = finalText.split("\n");
    for (let i = 0; i < lines.length; i++) onLine(lines[i]);
  }
  onProgress?.(total, total);
}

class Deduper {
  private buckets: Map<number, string[]> = new Map();
  private order: string[] = [];
  constructor(private options: Options) {}
  add(lineRaw: string): boolean {
    const n = normalizeLine(lineRaw, this.options);
    if (this.options.ignoreEmpty && n === "") return false;
    const h = hash32(n);
    const b = this.buckets.get(h);
    if (b) {
      for (const s of b) if (s === n) return false;
      b.push(n);
    } else {
      this.buckets.set(h, [n]);
    }
    this.order.push(this.options.preserveFirst ? lineRaw : n);
    return true;
  }
  getOutputLines() {
    return this.order;
  }
}

function hash32(s: string): number {
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
  }
  return h;
}

async function startMerge() {
  if (!mergeFiles.value.length) return;
  working.value = true;
  resetStatus();
  try {
    const nl = opts.outputCRLF ? NEWLINE_CRLF : NEWLINE_LF;
    const out: string[] = [];
    const deduper = mergeDedup.value ? new Deduper(opts) : null;

    const totalBytes = mergeFiles.value.reduce((a, f) => a + f.size, 0);
    let processedAll = 0;

    let idx = 0;
    for (const f of mergeFiles.value) {
      idx++;
      setStatus(
        `读取 ${f.name} (${idx}/${mergeFiles.value.length})...`,
        (processedAll / totalBytes) * 100
      );
      await streamFileLines(
        f,
        line => {
          status.lines++;
          if (mergeDedup.value) {
            if (deduper!.add(line)) {
              status.outputLines++;
            }
          } else {
            out.push(line);
            status.outputLines++;
          }
        },
        pb => {
          // 这里只能近似累计
          // 为了避免重复累加，同一文件内用局部进度无法准确合并，这里只更新显示
          setStatus(
            `读取 ${f.name} (${idx}/${mergeFiles.value.length})...`,
            Math.min(99, ((processedAll + pb) / totalBytes) * 100)
          );
        }
      );
      processedAll += f.size;
    }

    setStatus("生成结果...", 99);
    const text = mergeDedup.value
      ? deduper!.getOutputLines().join(nl)
      : out.join(nl);
    downloadText(text, "merged.txt");
    setStatus("完成", 100);
  } catch (e) {
    console.error(e);
    ElMessage.error("合并失败");
    setStatus("发生错误", 100);
  } finally {
    working.value = false;
  }
}

async function startDedupSingle() {
  if (!singleFile.value) return;
  working.value = true;
  resetStatus();
  try {
    const deduper = new Deduper(opts);
    await streamFileLines(
      singleFile.value,
      line => {
        status.lines++;
        if (deduper.add(line)) status.outputLines++;
      },
      (pb, tb) =>
        setStatus(`读取 ${singleFile.value!.name}...`, (pb / tb) * 100)
    );
    const nl = opts.outputCRLF ? NEWLINE_CRLF : NEWLINE_LF;
    const text = deduper.getOutputLines().join(nl);
    downloadText(
      text,
      `dedup_${singleFile.value.name.replace(/\.[^/.]+$/, "")}.txt`
    );
    setStatus("完成", 100);
  } catch (e) {
    console.error(e);
    ElMessage.error("去重失败");
    setStatus("发生错误", 100);
  } finally {
    working.value = false;
  }
}

async function startDiff() {
  if (!fileA.value || !fileB.value) return;
  working.value = true;
  resetStatus();
  try {
    // 载入B
    setStatus(`加载 B: ${fileB.value.name}...`, 5);
    const bSet = new Map<number, string[]>();
    await streamFileLines(
      fileB.value,
      line => {
        const n = normalizeLine(line, opts);
        if (opts.ignoreEmpty && n === "") return;
        const h = hash32(n);
        const bucket = bSet.get(h);
        if (bucket) {
          if (!bucket.includes(n)) bucket.push(n);
        } else {
          bSet.set(h, [n]);
        }
      },
      (pb, tb) =>
        setStatus(
          `加载 B: ${fileB.value!.name}...`,
          Math.max(5, (pb / tb) * 45)
        )
    );

    // 遍历A
    const out: string[] = [];
    await streamFileLines(
      fileA.value,
      line => {
        status.lines++;
        const n = normalizeLine(line, opts);
        if (opts.ignoreEmpty && n === "") return;
        const h = hash32(n);
        const bucket = bSet.get(h);
        const inB = bucket ? bucket.includes(n) : false;
        if (!inB) {
          out.push(opts.preserveFirst ? line : n);
          status.outputLines++;
        }
      },
      (pb, tb) =>
        setStatus(`对比 A: ${fileA.value!.name}...`, 45 + (pb / tb) * 50)
    );

    const nl = opts.outputCRLF ? NEWLINE_CRLF : NEWLINE_LF;
    downloadText(
      out.join(nl),
      `A_minus_B_${fileA.value.name.replace(/\.[^/.]+$/, "")}.txt`
    );
    setStatus("完成", 100);
  } catch (e) {
    console.error(e);
    ElMessage.error("对比去重失败");
    setStatus("发生错误", 100);
  } finally {
    working.value = false;
  }
}
</script>

<style scoped>
.el-card {
  border-radius: 10px;
}
</style>
